Проект "Рынок заведений общественного питания Москвы"¶

Оглавление

  • 1  Цели и задачи проекта
  • 2  Описание данных
  • 3  Загрузка и обзор данных
  • 4  Предобработка данных
    • 4.1  Изменение типов данных
    • 4.2  Проверка на дубликаты
    • 4.3  Обработка пропусков
    • 4.4  Анализ выбросов
    • 4.5  Создание новых столбцов
    • 4.6  Обзор данных после предобработки
  • 5  Анализ рынка общепита Москвы
    • 5.1  Итоговая оценка параметров для выбора места и типа заведения
  • 6  Выбор места для открытия кофейни
    • 6.1  Итоговая оценка параметров по выбору места для кофейни
➡️ [Ссылка на презентацию](https://disk.yandex.ru/i/o9Je8JblKOX3Gg)

Цели и задачи проекта¶

Описание проекта:
Доступен датасет с заведениями общественного питания Москвы, составленный на основе данных сервисов Яндекс Карты и Яндекс Бизнес на лето 2022 года. Информация, размещённая в сервисе Яндекс Бизнес, могла быть добавлена пользователями или найдена в общедоступных источниках. Необходимо подготовить исследование рынка заведений общественного питания Москвы, проанализировать его особенности и презентовать полученные результаты. Эти данные должны помочь инвесторам в выборе подходящего типа и расположения нового заведения, а также его меню и цен. Инвесторы помимо наиболее выгодного для открытия типа заведения хотели бы открыть как минимум одну кофейню, поэтому помимо общих рекомендаций по открытию нового заведения необходимо дать рекомендации по выбору места для открытия кофейни.

Цели исследования:

  • Дать рекомендации по типу и месту для открытия нового заведения общепита в Москве
  • Дать рекомендации по месту для открытия кофейни

Рабочие файлы:

  • moscow_places.csv - датасет с данными о заведениях общественного питания Москвы
  • admin_level_geomap.geojson - файл с границами районов Москвы

Инструменты:

  • Yupyter Notebook (Anaconda 2.3.2, Python 3.9.13)
    • Локальное окружение da_practicum_env.yml
    • Расширение toc2 для Jupyter Notebook
  • Библиотеки Python:
    • pandas 1.2.4
    • matplotlib 3.3.4
    • seaborn 0.11.1
    • plotly 5.4.0
    • folium 0.14.0
    • numpy 1.20.1
    • json 0.9.5
    • requests 2.28.1

Ход исследования:
Исследование пройдет в 3 этапа:

  1. Загрузка, обзор и подготовка данных - Ознакомимся с данными и подготовим их к анализу
  2. Анализ рынка общепита Москвы - Изучим особенности рынка заведений общепита в Москве и дадим рекомендации по типу и месту для открытия нового заведения
  3. Выбор места для открытия кофейни - Проанализируем данные и дадим рекомендации по месту для открытия новой кофейни

Описание данных¶

Файл moscow_places.csv:

  • name — название заведения;
  • category — категория заведения, например «кафе», «пиццерия» или «кофейня»;
  • address — адрес заведения;
  • district — административный район, в котором находится заведение, например Центральный административный округ;
  • hours — информация о днях и часах работы;
  • lat — широта географической точки, в которой находится заведение;
  • lng — долгота географической точки, в которой находится заведение;
  • rating — рейтинг заведения по оценкам пользователей в Яндекс Картах (высшая оценка — 5.0);
  • price — категория цен в заведении, например «средние», «ниже среднего», «выше среднего» и так далее;
  • avg_bill — строка, которая хранит среднюю стоимость заказа в виде диапазона, например:
    • «Средний счёт: 1000–1500 ₽»;
    • «Цена чашки капучино: 130–220 ₽»;
    • «Цена бокала пива: 400–600 ₽».
    • и так далее;
  • middle_avg_bill — число с оценкой среднего чека, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Средний счёт»:
    • Если в строке указан ценовой диапазон из двух значений, в столбец войдёт медиана этих двух значений.
    • Если в строке указано одно число — цена без диапазона, то в столбец войдёт это число.
    • Если значения нет или оно не начинается с подстроки «Средний счёт», то в столбец ничего не войдёт.
  • middle_coffee_cup — число с оценкой одной чашки капучино, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Цена одной чашки капучино»:
    • Если в строке указан ценовой диапазон из двух значений, в столбец войдёт медиана этих двух значений.
    • Если в строке указано одно число — цена без диапазона, то в столбец войдёт это число.
    • Если значения нет или оно не начинается с подстроки «Цена одной чашки капучино», то в столбец ничего не войдёт.
  • chain — число, выраженное 0 или 1, которое показывает, является ли заведение сетевым (для маленьких сетей могут встречаться ошибки):
    • 0 — заведение не является сетевым
    • 1 — заведение является сетевым
  • seats — количество посадочных мест.

Загрузка и обзор данных¶

Импортируем нужные для работы библиотеки

In [1]:
# библиотеки анализа данных
import pandas as pd
import numpy as np

# библиотеки визуализации
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go
import folium
from folium.features import CustomIcon
from folium.features import DivIcon
from folium.plugins import MarkerCluster

# прочие библиотеки
import json
import requests
import warnings
In [2]:
# отключим UserWarning
warnings.filterwarnings('ignore')

Загружаем данные.
Внимание: датасеты защищены авторским правом Яндекс Практикума и не приложены к проекту.

In [3]:
# функция для открытия csv-файла с запасным путем
def open_csv(path_1,name_file_1,path_2,name_file_2):
    way_1 = path_1 + name_file_1
    way_2 = path_2 + name_file_2
    try:
        df = pd.read_csv(way_1)
        print('Файл \033[1m' + name_file_1 + '\033[0m успешно прочитан по локальному пути')
    except:
        try:
            df = pd.read_csv(way_2)
            print('Файл \033[1m' + name_file_2 + '\033[0m успешно прочитан по сетевому пути')
        except:
            return print('Ошибка чтения файла \033[1m' + name_file_2 + '\033[0m')
    return df

# функция для открытия json-файла с запасным путем
def open_json(path_1,name_file_1,path_2,name_file_2):
    way_1 = path_1 + name_file_1
    way_2 = path_2 + name_file_2
    try:
        with open(way_1,encoding='utf-8') as f:
            state_geo = json.load(f)
        print('Файл \033[1m' + name_file_1 + '\033[0m успешно прочитан по локальному пути')
    except:
        try:
            response = requests.get(way_2)
            state_geo = json.loads(response.text)
            print('Файл \033[1m' + name_file_2 + '\033[0m успешно прочитан по сетевому пути')
        except:
            return print('Ошибка чтения файла \033[1m' + name_file_2 + '\033[0m')
    return state_geo
In [4]:
# загружаем csv-файл с данными о заведениях
path_local = ''
name_file_local = 'moscow_places.csv'
path_web = ''
name_file_web = 'moscow_places.csv'
data = open_csv(path_local,
              name_file_local,
              path_web,
              name_file_web)

# загружаем JSON-файл с границами округов Москвы
path_local = ''
name_file_local = 'admin_level_geomap.geojson'
path_web = '' # защищено авторским правом
name_file_web = 'admin_level_geomap.geojson'
state_geo = open_json(path_local,
              name_file_local,
              path_web,
              name_file_web)
Файл moscow_places.csv успешно прочитан по локальному пути
Файл admin_level_geomap.geojson успешно прочитан по локальному пути

Делаем обзор данных

In [5]:
# функция для обзора столбцов датафрейма

def data_info(data_import):
    
    # создаем столбцы с нужными данными и склеиваем их в один датафрейм
    data_info = (pd.DataFrame(data_import.dtypes,
                              columns=['Тип столбца'])
             .join(pd.DataFrame(data_import.count(),
                                columns=['Ненулевых значений']))
             .join(pd.DataFrame(data_import.nunique(),
                                columns=['Уникальных значений']))
             .join(pd.DataFrame(data_import.isna().sum(),
                                columns=['Пропусков']))
             .join(pd.DataFrame(data_import.isna().sum()/data_import.shape[0],
                                columns=['Процент пропусков'])))
    
    # выводим полученный датафрейм
    display(data_info.style.format({'Процент пропусков': '{:.0%}'}))

# функция для обзора датафрейма, вывода первых строк и гистограмм

def data_review (data_import):

    # выведем общую обзорную информацию о датафрейме  
    display(pd.DataFrame(data=[data_import.shape[0],
                               data_import.shape[1],
                               data_import.duplicated().sum()],
                         columns=['Всего в датасете'],
                         index=['Строк','Столбцов','Явных дубликатов']))                    
    
    # выводем информацию о столбцах датафрейма
    data_info(data_import)
    
    # выводем первые 5 строк
    print(display(data_import.head())) 
    
    # выведем гистограммы по числовым столбцам
    with plt.style.context('seaborn-darkgrid'):
        data.hist(figsize=(10, 10),ec='black')
        plt.show()
In [6]:
# делаем обзор датафрейма
data_review(data)
Всего в датасете
Строк 8406
Столбцов 14
Явных дубликатов 0
  Тип столбца Ненулевых значений Уникальных значений Пропусков Процент пропусков
name object 8406 5614 0 0%
category object 8406 8 0 0%
address object 8406 5753 0 0%
district object 8406 9 0 0%
hours object 7870 1307 536 6%
lat float64 8406 8209 0 0%
lng float64 8406 8258 0 0%
rating float64 8406 41 0 0%
price object 3315 4 5091 61%
avg_bill object 3816 897 4590 55%
middle_avg_bill float64 3149 230 5257 63%
middle_coffee_cup float64 535 96 7871 94%
chain int64 8406 2 0 0%
seats float64 4795 229 3611 43%
name category address district hours lat lng rating price avg_bill middle_avg_bill middle_coffee_cup chain seats
0 WoWфли кафе Москва, улица Дыбенко, 7/1 Северный административный округ ежедневно, 10:00–22:00 55.878494 37.478860 5.0 NaN NaN NaN NaN 0 NaN
1 Четыре комнаты ресторан Москва, улица Дыбенко, 36, корп. 1 Северный административный округ ежедневно, 10:00–22:00 55.875801 37.484479 4.5 выше среднего Средний счёт:1500–1600 ₽ 1550.0 NaN 0 4.0
2 Хазри кафе Москва, Клязьминская улица, 15 Северный административный округ пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00... 55.889146 37.525901 4.6 средние Средний счёт:от 1000 ₽ 1000.0 NaN 0 45.0
3 Dormouse Coffee Shop кофейня Москва, улица Маршала Федоренко, 12 Северный административный округ ежедневно, 09:00–22:00 55.881608 37.488860 5.0 NaN Цена чашки капучино:155–185 ₽ NaN 170.0 0 NaN
4 Иль Марко пиццерия Москва, Правобережная улица, 1Б Северный административный округ ежедневно, 10:00–22:00 55.881166 37.449357 5.0 средние Средний счёт:400–600 ₽ 500.0 NaN 1 148.0
None

Выводы о проблемах с данными:

Пропуски

  • В данных столбцах есть пропуски. Их стоит оценить и обработать, если это возможно:
    • hours
    • price
    • avg_bill
    • middle_avg_bill
    • middle_coffee_cup
    • seats

Дубликаты

  • Явных дубликатов нет. Стоит проверить категориальные столбцы на неявные дубликаты. В категориальных столбцах name, address, hours, avg_bill слишком много уникальных значений, поэтому проверить данные столбцы на неявные дубликаты не представляется возможным. На неявные дубликаты можно проверить только эти столбцы:
    • category
    • district
    • price

Названия столбцов

  • Имена столбцов менять не стоит, все они приведены в единому формату snake_case.

Типы данных

  • Тип данных столбца seats стоит поменять на целочисленный. Количество посадочных мест по определению не может быть дробным числом. Однако, учитывая там наличие пропусков, лучше оставить вещественный тип, иначе это можеть усложнить некоторые операции со столбцом.
  • Так как в столбце chain всего два уникальных значения, тип данных int для него избыточен, его можно поменять на более компактный тип bool.

Выбросы

  • Судя по гистограммам, скорее всего в некоторых числовых столбцах есть выбросы. Необходимо их проанализировать и обработать, если это необходимо.

Предобработка данных¶

Изменение типов данных¶

In [7]:
# меняем тип столбца chain на bool
data['chain'] = data['chain'].astype('bool')

Проверка на дубликаты¶

In [8]:
# проверяем на дубликаты столбец category
data['category'].sort_values().unique()
Out[8]:
array(['бар,паб', 'булочная', 'быстрое питание', 'кафе', 'кофейня',
       'пиццерия', 'ресторан', 'столовая'], dtype=object)
In [9]:
# проверяем на дубликаты столбец district
data['district'].sort_values().unique()
Out[9]:
array(['Восточный административный округ',
       'Западный административный округ',
       'Северный административный округ',
       'Северо-Восточный административный округ',
       'Северо-Западный административный округ',
       'Центральный административный округ',
       'Юго-Восточный административный округ',
       'Юго-Западный административный округ',
       'Южный административный округ'], dtype=object)
In [10]:
# проверяем на дубликаты столбец price
data['price'].sort_values().unique()
Out[10]:
array(['высокие', 'выше среднего', 'низкие', 'средние', nan], dtype=object)

Вывод: Неявных дубликатов нет. Их обработка не требуется.

Обработка пропусков¶

Во всех столбцах c пропусками кроме столбца hours очень много пропусков. Удаление строк с пропусками приведет к значительной потере данных, замена значений на медиану/моду может привести к значительному смещению распределения данных и искажению дальнейших расчетов. В связи с этим пропуски в числовых столбцах проигнорируем и оставим как есть. Пропуски в категориальных столбцах hours,price,avg_bill можно заменить заглушкой unknown.

In [11]:
# заполняем пропуски в столбцах
data['hours'] = data['hours'].fillna('unknown')
data['price'] = data['price'].fillna('unknown')
data['avg_bill'] = data['avg_bill'].fillna('unknown')

Анализ выбросов¶

Посмотрим, есть ли выбросы в данных.

In [12]:
# функция для построения графика плотности распределения,
# точечного графика-полоски, боксплота и вывода основых статистик

def describe_func(df,column):
    
    # выводим основные статистики
    print('********************')
    df_desc = pd.DataFrame(df[column].describe())
    quant_1 = df[column].quantile([0.25])[0.25]
    quant_3 = df[column].quantile([0.75])[0.75]
    IQR = quant_3 - quant_1
    df_desc.loc['75%+1.5*IQR'] = quant_3 + 1.5 * IQR
    df_desc.loc['25%-1.5*IQR'] = quant_1 - 1.5 * IQR
    display(df_desc.round(2))
    
    # задаем размер подложки для графиков
    fig = plt.subplots(figsize=(20,6))
    
    # задаем единый стиль графиков
    sns.set_style('darkgrid')
    
    # строим график плотности распределения
    ax1 = plt.subplot(1, 3, 1)
    sns.violinplot(y=column, data=df,ax=ax1)
    ax1.set_title('График плотности распределения {}'.format(column))
    ax1.set_ylabel('')
    
    # строим точечный график-полоску  
    ax2 = plt.subplot(1, 3, 2, sharey=ax1)
    sns.stripplot(y=column, data=df, ax=ax2, color='#3274a1')
    ax2.set_title('Точечный график распределения {}'.format(column))
    ax2.set_ylabel('')
    
    # строим боксплот
    ax3 = plt.subplot(1, 3, 3, sharey=ax1)
    sns.boxplot(y=column, data=df, ax=ax3)
    ax3.set_title('Боксплот {}'.format(column))
    ax3.set_ylabel('')
    
    # выводим графики
    plt.show();
In [13]:
# задаем список имен числовых столбцов
list_col = ['lat',
            'lng',
            'rating',
            'middle_avg_bill',
            'middle_coffee_cup',
            'seats']

# выводим обзор распределений
for name_col in list_col:
    describe_func(data,name_col)
********************
lat
count 8406.00
mean 55.75
std 0.07
min 55.57
25% 55.71
50% 55.75
75% 55.80
max 55.93
75%+1.5*IQR 55.93
25%-1.5*IQR 55.57
********************
lng
count 8406.00
mean 37.61
std 0.10
min 37.36
25% 37.54
50% 37.61
75% 37.66
max 37.87
75%+1.5*IQR 37.85
25%-1.5*IQR 37.35
********************
rating
count 8406.00
mean 4.23
std 0.47
min 1.00
25% 4.10
50% 4.30
75% 4.40
max 5.00
75%+1.5*IQR 4.85
25%-1.5*IQR 3.65
********************
middle_avg_bill
count 3149.00
mean 958.05
std 1009.73
min 0.00
25% 375.00
50% 750.00
75% 1250.00
max 35000.00
75%+1.5*IQR 2562.50
25%-1.5*IQR -937.50
********************
middle_coffee_cup
count 535.00
mean 174.72
std 88.95
min 60.00
25% 124.50
50% 169.00
75% 225.00
max 1568.00
75%+1.5*IQR 375.75
25%-1.5*IQR -26.25
********************
seats
count 4795.00
mean 108.42
std 122.83
min 0.00
25% 40.00
50% 75.00
75% 140.00
max 1288.00
75%+1.5*IQR 290.00
25%-1.5*IQR -110.00

Вывод по итогам анализа выбросов:
Cудя по визуализации распределений и вывода основных статистик, нет оснований считать, что в столбцах lat, lng есть существенные выбросы. Однако выбросы определенно есть в столбцах:

  • middle_avg_bill
  • middle_coffee_cup
  • seats
  • rating

Устранять выбросы в данных столбцах не будем, так как выбросы в них не похожи на явные ошибки. Выбросы в middle_avg_bill , middle_coffee_cup могут говорить об элитных заведениях с высокими ценами. Выбросы в seats — об аномально больших заведениях. Выбросы в rating — об аномально плохих заведениях.

Создание новых столбцов¶

Создадим столбец street с названиями улиц из столбца с адресом.

In [14]:
# создаем функцию для получения названия улицы
def street_search(string):
    list =string.split(", ")
    return list[1]

# создаем столбец с названием улицы
data['street'] = data['address'].apply(street_search)

Создадим столбец is_24/7 с обозначением, что заведение работает ежедневно и круглосуточно (24/7): логическое значение True — если заведение работает ежедневно и круглосуточно; логическое значение False — в противоположном случае.

In [15]:
# посмотрим на уникальные значения столбца hours
data['hours'].value_counts()
Out[15]:
ежедневно, 10:00–22:00                                                  759
ежедневно, круглосуточно                                                730
unknown                                                                 536
ежедневно, 11:00–23:00                                                  396
ежедневно, 10:00–23:00                                                  310
                                                                       ... 
пн-пт 17:00–03:00; сб,вс 17:00–05:00                                      1
пн,вт 09:00–21:00; ср-пт 09:00–22:00; сб 10:00–22:00; вс 10:00–21:00      1
пн-пт 12:00–01:00                                                         1
пн-пт 10:30–21:30; сб,вс 10:30–22:30                                      1
пн-сб 10:30–21:30                                                         1
Name: hours, Length: 1308, dtype: int64

Ежедневность работы определяется словом "ежедневно", круглосуточность — словом "круглосуточно". Создадим столбец с флагом 24/7.

In [16]:
# создадим функцию для определения флага 24/7
def word_search(string):
    if 'круглосуточно' in string.lower():
        if 'ежедневно' in string.lower():
            return True
        else:
            return False
    else: return False
        
# создаем столбец с флагом 24/7
data['is_24/7'] = data['hours'].apply(word_search)

Создадим еще один столбец с кратким названием района для удобства дальнейшего анализа данных по районам.

In [17]:
# задаем список полных названий районов
wrong_values = ['Северный административный округ',
               'Северо-Восточный административный округ',
               'Северо-Западный административный округ',
               'Западный административный округ',
               'Центральный административный округ',
               'Восточный административный округ',
               'Юго-Восточный административный округ',
               'Южный административный округ',
                'Юго-Западный административный округ']

# задаем список кратких названий районов
correct_values = ['САО',
                 'СВАО',
                 'СЗАО',
                 'ЗАО',
                 'ЦАО',
                 'ВАО',
                 'ЮВАО',
                 'ЮАО',
                 'ЮЗАО']

# создаем столбец с краткими названиями
data['dstr'] = data['district']
for index in range(len(wrong_values)):
        data['dstr'] = data['dstr'].replace(wrong_values[index], correct_values[index])

# для проверки выведем уникальные сочетания новых и старых названий
data.groupby(['district','dstr'])['name'].count().index
Out[17]:
MultiIndex([(       'Восточный административный округ',  'ВАО'),
            (        'Западный административный округ',  'ЗАО'),
            (        'Северный административный округ',  'САО'),
            ('Северо-Восточный административный округ', 'СВАО'),
            ( 'Северо-Западный административный округ', 'СЗАО'),
            (     'Центральный административный округ',  'ЦАО'),
            (   'Юго-Восточный административный округ', 'ЮВАО'),
            (    'Юго-Западный административный округ', 'ЮЗАО'),
            (           'Южный административный округ',  'ЮАО')],
           names=['district', 'dstr'])

Добавим площади округов в квадратный километрах, они понадобятся нам в дальнейшем. Площади округов взяты из Википедии

In [18]:
# задаем функцию для добавления площади района
def area_value(x):
    if x == 'Восточный административный округ':
        return 154.84
    elif x == 'Западный административный округ':
        return 153.03
    elif x == 'Северный административный округ':
        return 113.73
    elif x == 'Северо-Восточный административный округ':
        return 101.88
    elif x == 'Северо-Западный административный округ':
        return 93.28
    elif x == 'Центральный административный округ':
        return 66.18
    elif x == 'Юго-Восточный административный округ':
        return 117.56
    elif x == 'Юго-Западный административный округ':
        return 111.36
    elif x == 'Южный административный округ':
        return 131.77
    else:
        return 'error'

# добавляем площади в новый столбец
data['area'] = data['district'].apply(area_value)

Поменяем в столбце category название категории "быстрое питание" на более компактное.

In [19]:
data['category'] = data['category'].replace('быстрое питание', 'быстр.пит.')

Обзор данных после предобработки¶

In [20]:
# выводим информацию о столбцах
data_info(data)

# выводим первые 5 строк датафрейма
data.head()
  Тип столбца Ненулевых значений Уникальных значений Пропусков Процент пропусков
name object 8406 5614 0 0%
category object 8406 8 0 0%
address object 8406 5753 0 0%
district object 8406 9 0 0%
hours object 8406 1308 0 0%
lat float64 8406 8209 0 0%
lng float64 8406 8258 0 0%
rating float64 8406 41 0 0%
price object 8406 5 0 0%
avg_bill object 8406 898 0 0%
middle_avg_bill float64 3149 230 5257 63%
middle_coffee_cup float64 535 96 7871 94%
chain bool 8406 2 0 0%
seats float64 4795 229 3611 43%
street object 8406 1448 0 0%
is_24/7 bool 8406 2 0 0%
dstr object 8406 9 0 0%
area float64 8406 9 0 0%
Out[20]:
name category address district hours lat lng rating price avg_bill middle_avg_bill middle_coffee_cup chain seats street is_24/7 dstr area
0 WoWфли кафе Москва, улица Дыбенко, 7/1 Северный административный округ ежедневно, 10:00–22:00 55.878494 37.478860 5.0 unknown unknown NaN NaN False NaN улица Дыбенко False САО 113.73
1 Четыре комнаты ресторан Москва, улица Дыбенко, 36, корп. 1 Северный административный округ ежедневно, 10:00–22:00 55.875801 37.484479 4.5 выше среднего Средний счёт:1500–1600 ₽ 1550.0 NaN False 4.0 улица Дыбенко False САО 113.73
2 Хазри кафе Москва, Клязьминская улица, 15 Северный административный округ пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00... 55.889146 37.525901 4.6 средние Средний счёт:от 1000 ₽ 1000.0 NaN False 45.0 Клязьминская улица False САО 113.73
3 Dormouse Coffee Shop кофейня Москва, улица Маршала Федоренко, 12 Северный административный округ ежедневно, 09:00–22:00 55.881608 37.488860 5.0 unknown Цена чашки капучино:155–185 ₽ NaN 170.0 False NaN улица Маршала Федоренко False САО 113.73
4 Иль Марко пиццерия Москва, Правобережная улица, 1Б Северный административный округ ежедневно, 10:00–22:00 55.881166 37.449357 5.0 средние Средний счёт:400–600 ₽ 500.0 NaN True 148.0 Правобережная улица False САО 113.73

Что было cделано на этапе предобработки:

  • Тип столбца chain изменили на bool
  • Пропуски заменили заглушкой unknown в столбцах:
    • hours
    • price
    • avg_bill
  • Cоздали три новых столбца:
    • street с названием улицы
    • is_24/7 c флагом 24/7
    • dstr c кратким названием района
  • Заменили название категории быстрого питания в столбце category на более компактное
  • Добавили столбец area с площадью округов

Анализ рынка общепита Москвы¶

Посмотрим, какие категории заведений представлены в данных.

In [21]:
# задаем единый стиль для графиков
sns.set_style('dark')
In [22]:
# готовим данные для графика
df = pd.DataFrame(data['category'].value_counts()).reset_index()

# строим график
fig = go.Figure(data=[go.Pie(labels=df['index'], 
                             values=df['category'],
                             pull = [0.1, 0])])

# настраиваем параметры графика
fig.update_layout(title={'text': 'Число заведений по категориям','x': 0.6},
                  width=800,
                  height=600,
                  annotations=[dict(x=1,
                                    y=1,
                                    text='Тип заведения',
                                    showarrow=False)])
# выводим график
fig.show()
In [23]:
# готовим данные для графика
df = (data.groupby('category',as_index = False)['name'].count().
      rename(columns={'name': 'points_count'}).sort_values(by='points_count',ascending=False))

# строим график
fig = px.bar(df, 
             y='category',
             x='points_count',
             text='points_count',
             color='category')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Число заведений по категориям','x': 0.5},
                  xaxis_title='',
                  yaxis_title='',
                  width=850,
                  height=600)
fig.update_xaxes(tickvals=[])
fig.update_xaxes(title_text='Число заведений')
fig.update_yaxes(title_text='Категория заведения')

# выводим график
fig.show()

Вывод:

  • Больше всего кафе
  • Вместе кафе и рестораны составляют более половины всех заведений
  • Меньше всего булочных и столовых

Посмотрим на распределение посадочных мест по категориям заведений. В качестве наиболее характерного значения возьмем медиану, так как в посадочных местах есть выбросы.

In [24]:
# готовим данные для графика
df = data.groupby('category',as_index = False)['seats'].median().sort_values(by='seats')

# строим график
fig = px.bar(df, 
             y='category',
             x='seats',
             text='seats')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Медианное значение посадочных мест по категориям заведений','x': 0.5},
                  xaxis_title='',
                  yaxis_title='',
                  width=850,
                  height=600)
fig.update_xaxes(tickvals=[])
fig.update_xaxes(title_text='Число посадочных мест')
fig.update_yaxes(title_text='Категории заведений')

# выводим график
fig.show()

Выводы:

  • Больше всего посадочных мест в ресторанах
  • Меньше всего посадочных мест в булочных

Посмотрим на соотношение сетевых и несетевых заведений.

In [25]:
# готовим данные для графика
df = data.groupby('chain', as_index = False)['name'].count()
df = df.rename(columns={'name': 'chain_count'})
df['chain_perc'] = df['chain_count'] / df['chain_count'].sum() * 100
df['all'] = ''
df['chain_new_name'] = df['chain']
df['chain_new_name'] = df['chain_new_name'].replace(True,'Сетевое')
df['chain_new_name'] = df['chain_new_name'].replace(False,'Несетевое')

# строим график
fig = go.Figure(data=[go.Pie(labels=df['chain_new_name'], 
                             values=df['chain_perc'],
                             pull = [0.1, 0])])

# настраиваем параметры графика
fig.update_layout(title={'text': 'Отношение сетевых и несетевых заведений','x': 0.6},
                  width=800,
                  height=600,
                  annotations=[dict(x=1,
                                    y=1,
                                    text='Тип заведения',
                                    showarrow=False)])
# выводим график
fig.show()

Вывод:

  • Несетевых заведений больше, чем сетевых.

Посмотрим какие категории заведений чаще являются сетевыми.

In [26]:
# готовим данные для графика
df = data.groupby(['chain','category'], as_index = False)['name'].count()
df = df.rename(columns={'name': 'chain_count'})
df['all_categ'] = df.groupby(['category'])['chain_count'].transform(sum)
df['chain_perc'] = round(df['chain_count'] / df['all_categ'],4) * 100
df['chain_new_name'] = df['chain']
df['chain_new_name'] = df['chain_new_name'].replace(True,'Сетевое')
df['chain_new_name'] = df['chain_new_name'].replace(False,'Несетевое')
df=df.sort_values(by='chain_perc')

# строим график
fig = px.bar(df,
             x='chain_perc',
             y='category',
             color='chain_new_name',
             text = round(df['chain_perc']))

# настраиваем параметры графика
fig.update_layout(title={'text': 'Отношение сетевых и несетевых заведений '
                         'по категориям заведений','x': 0.485},
                  legend_title='Тип заведения',
                  xaxis_title='Процент',
                  yaxis_title='Категории заведений',
                  width=900,
                  height=400,
                  plot_bgcolor="white")

# выводим график
fig.show()

Выводы:

  • Чаще всего сетевыми являются булочные.
  • Реже всего сетевыми являются пабы/бары.

Выведем топ-15 сетевых заведений с наибольших количеством точек.

Для этого возьмем из датасета только сетевые заведения и сгруппируем их по названию и категории. Только по названию группировать не стоит, потому что названия заведений разных категорий могут повторяться, но вряд ли среди сетевых заведений в одном городе разные заведения одной категории будут называться одинаково. Хотя, конечно, одному и тому же сетевому заведению может быть установлена разная категория для разных его точек и это исказит подсчет, если группировать по имени и категории одновременно. В данном случае проверить корректность установки категорий сложно, поэтому предположим, что категории заведений в датасете указаны верно и в основном для одних и тех же сетевых заведений однообразно.

In [27]:
# строим график количества точек для топ-15 сетевых заведений
# в разбивке по названиям заведений с выделением типа категории заведения

# готовим данные для графика
df = (data[data['chain']]
      .groupby(['name','category'], as_index = False)['address'].count()
      .rename(columns={'address': 'points_count'})
      .sort_values(by='points_count',ascending=False).head(15)
      .sort_values(by='points_count'))

# строим график
fig = px.bar(df,
             y='name',
             x='points_count',
             text='points_count')

# настраиваем параметры графика
fig.update_layout(title={'text': 'ТОП-15 сетевых заведений по количеству точек','x': 0.55},
                  legend_title='Категория заведения',
                  xaxis_title='Количество точек',
                  yaxis_title='Имя заведения',
                  width=900,
                  height=600)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

# строим график количества заведений для топ-15 сетевых заведений
# в разбивке по категориям заведений

# готовим данные для графика
df1 = (df.groupby('category', as_index = False)['name'].count().sort_values(by='name', ascending=False)
       .rename(columns={'name': 'name_count'}))

# строим график
fig = px.bar(df1,
             y='category',
             x='name_count',
             text='name_count',
             color='category')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Количество заведений среди ТОП-15 заведений','x': 0.5},
                  legend_title='Категория заведения',
                  xaxis_title='Количество заведений',
                  yaxis_title='Категории заведений',
                  width=800,
                  height=300)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

# строим график количества точек для топ-15 сетевых заведений
# в разбивке по категориям заведений

# готовим данные для графика
df2 = df.groupby('category', as_index = False)['points_count'].sum().sort_values(by='points_count', ascending=False)

# строим график
fig = px.bar(df2,
             y='category',
             x='points_count',
             text='points_count',
             color='category')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Количество точек для ТОП-15 заведений','x': 0.5},
                  legend_title='Категория заведения',
                  xaxis_title='Количество точек',
                  yaxis_title='Категории заведений',
                  width=800,
                  height=300)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

Выводы:

  • Самая популярная сеть - Шоколадница.
  • Среди топ-15 самых больших сетей больше всего кофеен (как по количеству заведений, так и по количеству точек)

Посмотрим распределение заведений и их категорий по районам.

In [28]:
# строим столбчатую диаграмму
# распределения числа заведений по районам

# готовим данные для графика
df = (data.groupby('dstr', as_index = False)['name'].count()
      .rename(columns={'name': 'points_count'})
      .sort_values(by='points_count'))

# строим график
fig = px.bar(df, 
             y='dstr',
             x='points_count',
             text='points_count')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Количество заведений по районам','x': 0.5},
                  xaxis_title='Количество заведений',
                  yaxis_title='Район',
                  width=900,
                  height=600)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

# строим столбчатую диаграмму
# процентного распределения категорий заведений по районам

# готовим данные для графика
df = (data.groupby(['category','dstr'], as_index = False)['name'].count()
      .rename(columns={'name': 'points_count_categ_dstr'}))
df['all_points_dstr'] = df.groupby('dstr')['points_count_categ_dstr'].transform(sum)
df['perc_categ'] = df['points_count_categ_dstr'] / df['all_points_dstr'] * 100
df['all_points_categ'] = df.groupby('category')['points_count_categ_dstr'].transform(sum)
df = df.sort_values(by=['all_points_dstr','all_points_categ'])

# строим график
fig = px.bar(df,
             x='perc_categ',
             y='dstr',
             color='category',
             text = round(df['perc_categ']))

# настраиваем параметры графика
fig.update_layout(title={'text': 'Соотношение количества заведений в разных районах по категориям','x': 0.45},
                  legend_title='Категория заведения',
                  xaxis_title='Процент',
                  yaxis_title='Район',
                  width=950,
                  height=400,
                  plot_bgcolor="white")
fig.update_traces(textposition="inside")

# выводим график
fig.show()

# строим столбчатую диаграмму
# процентного распределения количества заведений в районах по категориям заведений

# готовим данные для графика
df = (data.groupby(['category','dstr'], as_index = False)['name'].count()
      .rename(columns={'name': 'points_count_categ_dstr'}))
df['all_points_categ'] = df.groupby('category')['points_count_categ_dstr'].transform(sum)
df['perc_dstr'] = df['points_count_categ_dstr'] / df['all_points_categ'] * 100
df['all_points_dstr'] = df.groupby('dstr')['points_count_categ_dstr'].transform(sum)
df = df.sort_values(by=['all_points_categ','all_points_dstr'])

# строим график
fig = px.bar(df,
             x='perc_dstr',
             y='category',
             color='dstr',
             text = round(df['perc_dstr']))

# настраиваем параметры графика
fig.update_layout(title={'text': 'Соотношение количества заведений в разных категориях по районам','x': 0.46},
                  legend_title='Категория заведения',
                  xaxis_title='Процент',
                  yaxis_title='Категории заведений',
                  width=950,
                  height=400,
                  plot_bgcolor="white")
fig.update_traces(textposition="inside")

# выводим график
fig.show()

# строим хитмеп
# количества заведений по районам и категориями

# готовим данные для хитмепа
data_pivot = data.pivot_table(index = 'dstr', columns = 'category',
                                 values = 'name', aggfunc = 'count')
data_pivot['sum_all_1'] = data_pivot.sum(axis=1)
data_pivot=data_pivot.sort_values(by='sum_all_1')
data_pivot=data_pivot.drop('sum_all_1', axis=1)
data_pivot=data_pivot.T
data_pivot['sum_all_2'] = data_pivot.sum(axis=1)
data_pivot=data_pivot.sort_values(by='sum_all_2',ascending=False)
data_pivot=data_pivot.drop('sum_all_2', axis=1)

# строим хитмеп
plt.figure(figsize=(10, 5))
ax = sns.heatmap(
    data_pivot,
    annot=True,
    fmt='.0f',
    cmap= 'coolwarm',
    linecolor='black',
    linewidths=2)

# настраиваем параметры хитмепа
plt.xlabel('x_label')
ax.xaxis.set_ticks_position('top')
plt.title('Количество заведений по категориям и районам\n')
plt.xlabel('')
plt.ylabel('')

# выводим хитмеп
plt.show()

Выводы:

  • Больше всего заведений общепита в ЦАО. Меньше всего - в СЗАО.
  • Сравнение районов по категориям заведений:
    • Во всех округах на первом месте по количеству заведений идут кафе, на втором - рестораны. Кроме ЦАО: в нем на первом месте идут рестораны, на втором - кафе. Во всех округах меньше всего булочных. Кроме ЮЗАО и ЗАО: в них меньше всего столовых.
  • Сравнение категорий заведений по районам:
    • Количество заведений общепита любых категорий больше всего в ЦАО. По количеству заведений внутри одной категории ЦАО наиболее явный лидер по пабам/барам (т.е. в ЦАО больше всего процент пабов/баров от всех пабов/баров в Москве), наименее явный — по быстрому питанию. Количество заведений общепита любых категорий меньше всего в СЗАО, кроме столовых - от всех столовых в Москве их меньше всего в ЮЗАО. СЗАО наиболее явный антилидер по проценту пабов/баров, наименее явный — по пиццериям.

Посмотрим дополнительно на число заведений на 1 кв.км. по районам

In [29]:
# создадим функцию для построения хороплета

def map_chor(df_in,
             column_1,
             column_2,
             legend_name,
             nan_fill_opacity = 0,
             markers_on=False,
             clusters_on=False,
             fill_opacity=0.8,
             zoom_start=10):

    # выводим таблицу
    if (markers_on==False) and (clusters_on==False):
        display(df_in[[column_1, column_2]].sort_values(by=column_2,ascending=False).reset_index(drop=True))

    # задаем широту и долготу центра карты
    moscow_lat, moscow_lng = 55.751244, 37.618423

    # создаём карту
    m = folium.Map(location=[moscow_lat, moscow_lng], zoom_start=zoom_start)
    
    # если нужно нанести маркеры
    if markers_on == True:
        def create_marker(row):
            folium.Marker([row['lat'], row['lng']],
                popup=f"{row['name']} {row['rating']}",
                         fill_color='blue', 
                          color="blue", 
                          radius = 2, fill_opacity = 1
            ).add_to(m)
        df.apply(create_marker, axis=1)
        # создаем пустой столбец для отображения границ округов
        df[column_2] = 0
    
    # если нужно создать кластеры
    if clusters_on == True:
        marker_cluster = MarkerCluster().add_to(m)
        def create_clusters(row):
            folium.Marker(
                [row['lat'], row['lng']],
                popup=f"{row['name']} {row['rating']}",
            ).add_to(marker_cluster)
        data.apply(create_clusters, axis=1)
        # создаем пустой столбец для отображения границ округов
        df[column_2] = 0

    # создаём хороплет
    folium.Choropleth(
        geo_data=state_geo,
        data=df_in,
        columns=[column_1, column_2],
        key_on='feature.name',
        fill_color='YlGn',
        fill_opacity=fill_opacity,
        legend_name=legend_name,
        nan_fill_opacity=nan_fill_opacity,
        highlight=True
    ).add_to(m)

    # наносим на карту названия округов
    folium.map.Marker(
        [moscow_lat+0.02, moscow_lng-0.02],
        icon=DivIcon(html='<div style="font-size: 15pt">ЦАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat+0.06, moscow_lng+0.1],
        icon=DivIcon(html='<div style="font-size: 15pt">ВАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat+0.12, moscow_lng-0.015],
        icon=DivIcon(html='<div style="font-size: 15pt">СВАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat+0.11, moscow_lng-0.13],
        icon=DivIcon(html='<div style="font-size: 15pt">САО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat+0.06, moscow_lng-0.2],
        icon=DivIcon(html='<div style="font-size: 15pt">СЗАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat-0.03, moscow_lng-0.16],
        icon=DivIcon(html='<div style="font-size: 15pt">ЗАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat-0.11, moscow_lng-0.13],
        icon=DivIcon(html='<div style="font-size: 15pt">ЮЗАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat-0.12, moscow_lng],
        icon=DivIcon(html='<div style="font-size: 15pt">ЮАО</div>')).add_to(m)
    folium.map.Marker(
        [moscow_lat-0.04, moscow_lng+0.11],
        icon=DivIcon(html='<div style="font-size: 15pt">ЮВАО</div>')).add_to(m)

    # выводим карту    
    return m
In [30]:
# считаем число заведений на 1 кв.км. по районам
df = data.groupby(['district','area'], as_index = False)['name'].count()
df['count_per_area'] = round(df['name'] / df['area'],2)

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='count_per_area',
         legend_name='Число заведений на 1 кв.км.')
district count_per_area
0 Центральный административный округ 33.88
1 Северо-Восточный административный округ 8.75
2 Северный административный округ 7.91
3 Южный административный округ 6.77
4 Юго-Западный административный округ 6.37
5 Юго-Восточный административный округ 6.07
6 Западный административный округ 5.56
7 Восточный административный округ 5.15
8 Северо-Западный административный округ 4.38
Out[30]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • По числу заведений на 1 кв.км ЦАО - явный лидер

Чтобы визуально лучше сравнить разницу в числе заведений на 1 кв.км по районам, посмотрим дополнительно на хороплет без ЦАО.

In [31]:
# считаем число заведений на 1 кв.км. по районам без ЦАО
df = (data[data['district'] != 'Центральный административный округ']
      .groupby(['district','area'], as_index = False)['name'].count())
df['count_per_area'] = round(df['name'] / df['area'],2)

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='count_per_area',
         legend_name='Число заведений на 1 кв.км.',
         nan_fill_opacity=0.2)
district count_per_area
0 Северо-Восточный административный округ 8.75
1 Северный административный округ 7.91
2 Южный административный округ 6.77
3 Юго-Западный административный округ 6.37
4 Юго-Восточный административный округ 6.07
5 Западный административный округ 5.56
6 Восточный административный округ 5.15
7 Северо-Западный административный округ 4.38
Out[31]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • Если не брать в расчет ЦАО, число заведений на 1 кв. км. выше всего в СВАО, меньше всего - в СЗАО.

Посмотрим на распределения рейтингов заведений по категориям заведений.

In [32]:
# готовим данные для графиков
df = data.copy()
df['median_rat_categ'] = data.groupby('category')['rating'].transform('median')
df['mean_rat_categ'] = data.groupby('category')['rating'].transform('mean')
df = df.sort_values(by='median_rat_categ')

# задаем размер подложки
fig = plt.subplots(figsize=(15,5))

# строим боскплоты рейтингов
ax1 = plt.subplot(1, 2, 1)
ax1 = sns.boxplot(x='category', y='rating', data=df, showfliers=False)
plt.ylim(3.2, 5.1)
ax1.set_xticklabels(ax1.get_xticklabels(),rotation = 90)
ax1.set_title('Распределения рейтингов заведений по категориям заведений')
ax1.set_xlabel('Категории заведений')
ax1.set_ylabel('Значение рейтинга')

# строим линейный график значений среднего и медианного рейтинга
sns.set_style('darkgrid')
ax2 = plt.subplot(1, 2, 2)
ax2 = sns.lineplot(x='category', y='median_rat_categ', data=df, label='Медиана', marker='o')
ax2 = sns.lineplot(x='category', y='mean_rat_categ', data=df, label='Среднее', marker='o')
ax2.set_xticklabels(ax1.get_xticklabels(), rotation=90)
ax2.set_title('Средний и медианный рейтинги по категориям заведений')
ax2.set_xlabel('Категории заведений')
ax2.set_ylabel('Значение рейтиния')

# выводим график
plt.show()

# вернем стиль графиков к исходному
sns.set_style('dark')

Вывод:

  • В быстром питании и кафе явно ниже рейтинги, чем по остальным категориям (минимальные значения медианы и среднего).
  • Выше всего рейтинги у пабов/баров (максимальные значения медианы и среднего).
  • Стоит отметить, что относительные различия даже в среднем рейтинге небольшие: максимальный разброс относительных различий в рейтинге (т.е. во сколько раз максимальное значение рейтинга (в данном случае баров) выше минимального (в данном случае быстрого питания)) составляет лишь около 8%

Построим фоновую картограмму со средним рейтингом заведений каждого района.

In [33]:
# считаем для каждого округа средний рейтинг заведений
df = (data.groupby('district', as_index=False)['rating'].agg('mean').round(2)
      .rename(columns={'rating': 'rating_mean'}))

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='rating_mean',
         legend_name='Средний рейтинг заведений')
district rating_mean
0 Центральный административный округ 4.38
1 Северный административный округ 4.24
2 Северо-Западный административный округ 4.21
3 Западный административный округ 4.18
4 Южный административный округ 4.18
5 Восточный административный округ 4.17
6 Юго-Западный административный округ 4.17
7 Северо-Восточный административный округ 4.15
8 Юго-Восточный административный округ 4.10
Out[33]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Дополнительно построим фоновую картограмму с медианным рейтингом заведений каждого района.

In [34]:
# считаем для каждого округа медианный рейтинг заведений
df = (data.groupby('district', as_index=False)['rating'].agg('median').round(2)
      .rename(columns={'rating': 'rating_mean'}))

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='rating_mean',
         legend_name='Средний рейтинг заведений')
district rating_mean
0 Центральный административный округ 4.4
1 Восточный административный округ 4.3
2 Западный административный округ 4.3
3 Северный административный округ 4.3
4 Северо-Западный административный округ 4.3
5 Юго-Западный административный округ 4.3
6 Южный административный округ 4.3
7 Северо-Восточный административный округ 4.2
8 Юго-Восточный административный округ 4.2
Out[34]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • В ЦАО самый высокий как средний, так и медианный рейтинг заведений.
  • Самые низкий средний рейтинг - в ЮВАО, самый низкий медианный - в ЮВАО И СВАО.

Отобразим все заведения на карте с помощью кластеров.

In [35]:
# готовим данные
df = pd.DataFrame(data['district'].unique()).rename(columns={0: 'district'})

# наносим на карту кластеры и границы округов
map_chor(df_in=df,
         column_1='district',
         column_2='buf',
         legend_name='',
         clusters_on=True,
         fill_opacity=0)
Out[35]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • Границы многих районов уходят за МКАД, однако за МКАДом почти нет заведений. В данных по каким-то причинам есть перекос в сторону заведений внутри МКАДа, стоит отметить эту особенность в данных.

Найдем топ-15 улиц по количеству заведений и посмотрим на распределение категорий заведений по этим улицам.

In [36]:
# строим столбчатую диаграмму
# распределения числа заведений по улицам

# готовим данные для графика
df = (data.groupby('street', as_index = False)['name'].count()
      .rename(columns={'name': 'points_count'})
      .sort_values(by='points_count', ascending=False).head(15)
      .sort_values(by='points_count'))

# строим график
fig = px.bar(df, 
             y='street',
             x='points_count',
             text='points_count')

# настраиваем параметры графика
fig.update_layout(title={'text': 'ТОП-15 улиц по количеству заведений','x': 0.55},
                  xaxis_title='Количество заведений',
                  yaxis_title='Название улицы',
                  width=950,
                  height=600)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

# строим столбчатую диаграмму
# процентного распределения категорий заведений по топ-15 улиц

# готовим данные для графика
top_street = (data.groupby('street')['name'].count()
              .sort_values(ascending=False).head(15).index)
df = data[data['street'].isin(top_street)]
df = (df.groupby(['category','street'], as_index = False)['name'].count()
      .rename(columns={'name': 'points_count_categ_street'}))
df['all_points_street'] = df.groupby('street')['points_count_categ_street'].transform(sum)
df['perc_categ'] = df['points_count_categ_street'] / df['all_points_street'] * 100
df['all_points_categ'] = df.groupby('category')['points_count_categ_street'].transform(sum)
df = df.sort_values(by='all_points_categ')

# строим график
fig = px.bar(df,
             x='perc_categ',
             y='street',
             color='category',
             text = round(df['perc_categ']))

# настраиваем параметры графика
fig.update_layout(title={'text': 'Соотношение количества заведений на ТОП-15 улицах по категориям','x': 0.5},
                  legend_title='Категория заведения',
                  xaxis_title='Процент',
                  yaxis_title='Название улицы',
                  width=950,
                  height=600,
                  plot_bgcolor="white")
fig.update_traces(textposition="inside")

# выводим график
fig.show()

# строим столбчатую диаграмму
# процентного распределения количества заведений на ТОП-15 улиц по категориям заведений

# готовим данные для графика
top_street = (data.groupby('street')['name'].count()
              .sort_values(ascending=False).head(15).index)
df = data[data['street'].isin(top_street)]
df = (df.groupby(['category','street'], as_index = False)['name'].count()
      .rename(columns={'name': 'points_count_categ_street'}))
df['all_points_categ'] = df.groupby('category')['points_count_categ_street'].transform(sum)
df['perc_street'] = df['points_count_categ_street'] / df['all_points_categ'] * 100
df['all_points_street'] = df.groupby('street')['points_count_categ_street'].transform(sum)
df = df.sort_values(by=['all_points_street','all_points_categ'])

# расширим палитру цветов
colors = px.colors.qualitative.Plotly[0:10] + px.colors.qualitative.Set2[0:10]

# строим график
fig = px.bar(df,
             x='perc_street',
             y='category',
             color='street',
             text = round(df['perc_street']),
             color_discrete_sequence=colors)

# настраиваем параметры графика
fig.update_layout(title={'text': 'Соотношение количества заведений в разных категориях по ТОП-15 улицам','x': 0.42},
                  legend_title='Название улицы',
                  xaxis_title='Процент',
                  yaxis_title='Название категорий',
                  width=950,
                  height=500,
                  plot_bgcolor="white")
fig.update_traces(textposition="inside")

# выводим график
fig.show()

# строим хитмпеп
# распределения числа заведений по улицам и категориям

# готовим данные для хитмепа
top_street = (data.groupby('street')['name'].count()
              .sort_values(ascending=False).head(15).index)
df = data[data['street'].isin(top_street)]
data_pivot = df.pivot_table(index = 'street', columns = 'category',
                                 values = 'name', aggfunc = 'count')
data_pivot['sum_all_1'] = data_pivot.sum(axis=1)
data_pivot=data_pivot.sort_values(by='sum_all_1',ascending=False)
data_pivot=data_pivot.drop('sum_all_1', axis=1)
data_pivot=data_pivot.T
data_pivot['sum_all_2'] = data_pivot.sum(axis=1)
data_pivot=data_pivot.sort_values(by='sum_all_2')
data_pivot=data_pivot.drop('sum_all_2', axis=1)
data_pivot=data_pivot.T

# строим хитмеп
plt.figure(figsize=(10, 7))
ax = sns.heatmap(
    data_pivot,
    annot=True,
    fmt='.0f',
    cmap= 'coolwarm',
    linecolor='black',
    linewidths=2)

# настраиваем параметры хитмепа
ax.tick_params(axis='x', labelsize=9)
ax.set_facecolor('#464544')
plt.xlabel('x_label')
ax.xaxis.set_ticks_position('top')
plt.title('Количество заведений по категориям и ТОП-15 улицам\n')
plt.xlabel('')
plt.ylabel('')

# выводим хитмеп
plt.show()

Вывод:

  • Среди ТОП-15 улиц по количеству заведений больше всего заведений на проспекте Мира. Меньше всего - на Пятницкой улице.
  • Сравнение ТОП-15 улиц по категориям заведений:
    • Каких заведений на каждой из ТОП-15 улиц больше всего:
      • проспект Мира - Кафе
      • Профсоюзная улица - Кафе
      • проспект Вернадского - Рестораны
      • Ленинский проспект - Рестораны
      • Ленинградский проспект - Рестораны, кофейни
      • Дмитровское шоссе - Рестораны
      • Каширское шоссе - Кафе
      • Варшавское шоссе - Рестораны
      • Ленинградское шоссе - Рестораны
      • МКАД - Кафе
      • Люблинская улица - Кафе
      • улица Вавилова - Кафе
      • Кутузовский проспект - Рестораны
      • улица Миклухо-Маклая - Кафе
      • Пятницкая улица - Рестораны
    • Каких заведений на каждой из ТОП-15 улиц меньше всего:
      • проспект Мира - Столовая
      • Профсоюзная улица - Столовая
      • проспект Вернадского - Булочная
      • Ленинский проспект - Быстрое питание
      • Ленинградский проспект - Быстрое питание
      • Дмитровское шоссе - Булочная
      • Каширское шоссе - Булочная
      • Варшавское шоссе - Булочная
      • Ленинградское шоссе - Булочная
      • МКАД - Булочная, пиццерия
      • Люблинская улица - Булочная
      • улица Вавилова - Столовая
      • Кутузовский проспект - Булочная
      • улица Миклухо-Маклая - Булочная, столовая
      • Пятницкая улица - Столовая
  • Сравнение категорий заведений по ТОП-15 улицам
    • На каких из ТОП-15 улиц больше всего заведений определенной категории (т.е. ищем на какой улице больше всего процент заведений определенной категории от всех заведений данной категории по Москве):
      • Кафе - Проспект Мира
      • Рестораны - Проспект Мира
      • Кофейни - Проспект Мира
      • Бары/пабы - Ленинградский проспект
      • Пиццерии- Профсоюзная улица
      • Быстрое питание - Проспект Мира
      • Столовые - Варшавское шоссе
      • Булочные - Ленинградский проспект, Профсоюзная улица, Проспект Мира
    • На каких из ТОП-15 улиц меньше всего заведений определенной категории (т.е. ищем на какой улице меньше всего процент заведений определенной категории от всех заведений данной категории по Москве):
      • Кафе - Пятницкая улица
      • Рестораны - МКАД
      • Кофейни - улица Миклухо-Маклая, МКАД
      • Бары/пабы - МКАД
      • Пиццерии- МКАД
      • Быстрое питание - Пятницкая улица, Кутузовский проспект, Ленинский проспект, Ленинградский проспект
      • Столовые - улица Вавилова, улица Миклухо-Маклая, Пятницкая улица
      • Булочные - Каширское шоссе, Варшавское шоссе, МКАД, Люблинская улица, улица Миклухо-Маклая

Посмотрим на улицы, на которых находится только один объект общепита.

In [37]:
# готовим данные для графика
df = (data.groupby('street', as_index = False)['name'].count()
      .rename(columns={'name': 'points_in_street'}))
list_street = df[df['points_in_street'] == 1]['street'].unique()
df = (data[data['street'].isin(list_street)].groupby('category', as_index = False)['name'].count()
      .rename(columns={'name': 'points_in_street'})
      .sort_values(by='points_in_street', ascending=False))

# строим график
fig = px.bar(df, 
             y='category',
             x='points_in_street',
             color='category',
             text='points_in_street')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Количество "заведений-одиночек" по категориям','x': 0.5},
                  legend_title='Категория заведения',
                  xaxis_title='Количество заведений',
                  yaxis_title='Категория заведения',
                  width=850,
                  height=500)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()

Вывод:

  • На улицах с одним объектом общепита в основном находятся кафе. Меньше всего среди таких заведений булочных.

Построим фоновую картограмму медианного значения среднего счета по районам. Лучше брать медиану, а не среднее, так как в ценах есть выбросы.

In [38]:
# считаем для каждого округа средний рейтинг заведений
df = (data.groupby('district', as_index=False)['middle_avg_bill'].median()
             .rename(columns={'middle_avg_bill': 'median_avg_bill'}))

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='median_avg_bill',
         legend_name='Медианный чек')
district median_avg_bill
0 Западный административный округ 1000.0
1 Центральный административный округ 1000.0
2 Северо-Западный административный округ 700.0
3 Северный административный округ 650.0
4 Юго-Западный административный округ 600.0
5 Восточный административный округ 575.0
6 Северо-Восточный административный округ 500.0
7 Южный административный округ 500.0
8 Юго-Восточный административный округ 450.0
Out[38]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Выводы:

  • Наиболее высокий медианный чек в ЦАО и ЗАО
  • Наиболее низкий медианный чек в ЮВАО.

Итоговая оценка параметров для выбора места и типа заведения¶

Попробуем более подробно оценить ключевые параметры по выбору места и типа заведения и сделать итоговую оценку на основе общего влияния данных параметров.

Открывать новое заведение стоит там, где меньше конкуренция.

  • Прямые конкуренты - это конкуренты того же типа (например, конкуренция кафе среди кафе).
  • Непрямые конкуренты - это конкуренты среди остальных заведений общепита (например, кафе могут также конкурировать с ресторанами за клиентов).

На конкуренцию влияют следующие параметры (чем ниже любой из этих параметров - тем ниже конкуренция):

  1. Абсолютное число прямых заведений-конкурентов
  2. Абсолютное число непрямых заведений-конкурентов
  3. Число прямых заведений-конкурентов на единицу площади
  4. Число непрямых заведений-конкурентов на единицу площади
  5. Средний рейтинг прямых заведений-конкурентов
  6. Средний рейтинг непрямых заведений-конкурентов

Построим два хитмепа отнормированных параметров по районам и категориям заведений. Эти графики понадобятся для общей оценки параметров конкуренции по всем заведениям в районах (т.е. для сравнения районов по количеству непрямых конкурентов) и категориях (для сравнений категорий по общему количеству прямых конкурентов по всем районам). Под нормировкой в данном случае понимается, что значения по каждому параметру берутся относительно минимального значения параметра.

In [39]:
# функция для построения хитмепа отнормированных значений параметров по районам
# для выбора района

def heatmap_dstr(data_in, column, title):    
    
    # считаем количество заведений по районам
    df1 = (data_in.groupby(column, as_index = False)['name'].count()
        .rename(columns={'name': 'count'}))

    # считаем средний рейтинг заведений по районам
    df2 = (data_in.groupby(column, as_index=False)['rating']
          .agg('mean').rename(columns={'rating': 'rating_mean'}))
    
    # считаем число заведений на 1 кв км по районам
    df3 = data_in.groupby(['dstr','area'], as_index = False)['name'].count()
    df3['count_per_area'] = df3['name'] / df3['area']
    df3=df3[['dstr','count_per_area']]

    # объединяем все параметры в одну таблицу
    df_vs = (df1
             .merge(df2, on=column)
             .merge(df3, on=column))
    
    # помещаем названия районов в индексы
    df_vs = df_vs.set_index(column)

    # нормируем показатели каждого параметра
    for column in df_vs.columns:
        df_vs[column] = ((df_vs[column] - df_vs[column].min()) 
                         / (df_vs[column].min()))
    
    # переименуем столбцы
    df_vs=df_vs.rename(columns={'rating_mean': 'Средний рейтинг',
                           'count_per_area': 'Число заведений на 1 кв. км.',
                           'count': 'Число заведений'})
    # сортируем данные
    df_vs['mean_all_1'] = df_vs.mean(axis=1)
    df_vs=df_vs.sort_values(by='mean_all_1')
    df_vs=df_vs.drop('mean_all_1', axis=1)
    df_vs=df_vs.T
    df_vs['mean_all_2'] = df_vs.mean(axis=1)
    df_vs=df_vs.sort_values(by='mean_all_2',ascending=False)
    df_vs=df_vs.drop('mean_all_2', axis=1)

    # строим хитмеп параметров
    plt.figure(figsize=(9, 2))
    ax = sns.heatmap(
        df_vs,
        annot=True,
        cmap= 'coolwarm',
        linecolor='black',
        linewidths=2,
        fmt='.2f',
        vmin=0,
        vmax=1)

    # настраиваем параметры хитмепа
    ax.xaxis.set_ticks_position('top')
    plt.title(title)
    plt.xlabel('')
    plt.ylabel('')

    # выводим хитмеп
    plt.show()
In [40]:
# функция для построения хитмепа отнормированных значений параметров по категориям
# для выбора категории

def heatmap_categ(data_in, column, title):    
    
    # считаем количество заведений по категориям заведений
    df1 = (data_in.groupby(column, as_index = False)['name'].count()
        .rename(columns={'name': 'count'}))

    # считаем средний рейтинг заведений по категориям заведений
    df2 = (data_in.groupby(column, as_index=False)['rating']
          .agg('mean').rename(columns={'rating': 'rating_mean'}))

    # объединяем все параметры в одну таблицу
    df_vs = (df1
             .merge(df2, on=column))

    # помещаем названия районов в индексы
    df_vs = df_vs.set_index(column)

    # нормируем показатели каждого параметра
    for column in df_vs.columns:
        df_vs[column] = ((df_vs[column] - df_vs[column].min()) 
                         / (df_vs[column].min()))
        
    # переименуем столбцы
    df_vs=df_vs.rename(columns={'rating_mean': 'Средний рейтинг',
                           'count': 'Число заведений'})
    
    # сортируем данные
    df_vs['mean_all_1'] = df_vs.mean(axis=1)
    df_vs=df_vs.sort_values(by='mean_all_1')
    df_vs=df_vs.drop('mean_all_1', axis=1)
    df_vs=df_vs.T
    df_vs['mean_all_2'] = df_vs.mean(axis=1)
    df_vs=df_vs.sort_values(by='mean_all_2',ascending=False)
    df_vs=df_vs.drop('mean_all_2', axis=1)

    # строим хитмеп параметров
    plt.figure(figsize=(9, 1.5))
    ax = sns.heatmap(
        df_vs,
        annot=True,
        cmap= 'coolwarm',
        linecolor='black',
        linewidths=2,
        fmt='.2f',
        vmin=0,
        vmax=1)

    # настраиваем параметры хитмепа
    ax.tick_params(axis='x', labelsize=9)
    ax.xaxis.set_ticks_position('top')
    plt.title(title)
    plt.xlabel('')
    plt.ylabel('')

    # выводим хитмеп
    plt.show()

Пояснение к хитмепам, построенным ниже:

  • Чем холоднее ячейки - тем меньше конкуренция по параметру, чем теплее - тем выше конкуренция по параметру.
  • Цвет ячеек показывает разницу в значениях относительно минимального значения внутри одного параметра (например, если на хитмепе у параметра "Число заведений" значение 2 - это значит, что эта ячейка содержит в два раза больше числа заведений, чем в ячейке с минимальным количеством заведений)
  • За верхний предел покраски установлено значение 1. То есть все самые красные значения - это значения, которые в два или более раза больше минимального значения внутри одного параметра.
In [41]:
# строим хитмеп по районам
heatmap_dstr(data_in=data,
            column='dstr',
            title='Значения параметров конкуренции по районам\n')

Вывод:

  • Видно, что на конкуренцию по районам в основном влияют число заведений и число заведений на 1 кв. км. Изменения в среднем рейтинге незначительны.
  • По числу заведений и числу заведений на единицу площади явно выбивается СЗАО. То есть это район лучше всего по показателю непрямых конкурентов.
In [42]:
# строим хитмеп по типам заведений
heatmap_categ(data_in=data,
            column='category',
            title='Значения параметров конкуренции по типам заведений\n')

Вывод:

  • Видно, что на конкуренцию по категориям в основном влияет число заведений. Изменения в среднем рейтинге незначительны.
  • По числу заведений меньше всего конкурентов среди булочных и столовых. То есть в среднем по всем районам среди булочных и столовых меньше всего прямых конкурентов.

Детализируем выбор типа и района для нового заведения. Построим хитмепы для уникальных сочетаний типа заведения и района по абсолютному числу заведений, числу заведений на единицу площади и среднему рейтингу. Они понадобятся для сравнительной оценки параметров конкуренции по прямым конкурентам (т.е. для сравнения параметров одних и тех же типов заведений в одном и том же районе). Это поможет понять заведения каких типов в каких районах открывать лучше всего.

In [43]:
# функция для построения хитмепа значений по уникальным сочетаниям
# района и категории заведения

def heatmap_norm(data_in,     # передаем датафрейм
                 column1,     # по этому столбцу собираем районы
                 column2,     # по этому столбцу собираем категории
                 values,      # по этому столбцу получаем значения
                 aggfunc,     # функция для получения значений
                 title,       # задаем заголовок графику
                 values_2=0,  # доп. значения для доп. расчетов
                 aggfunc_2=0, # доп. функция для дополнительных расчетов
                 fmt='.2f',   # задаем округление значений
                 vmax=1       # устанавливает верхнюю границу покраски
                ):

    # собираем сводную таблицу
    data_pivot = data_in.pivot_table(index = column1, columns = column2,
                                     values = values, aggfunc = aggfunc)
    
    # эта часть выполняется, когда нужно посчитать число заведений на ед. площади
    if values_2 != 0:
        data_pivot_2 = data_in.pivot_table(index = column1, columns = column2,
                                         values = values_2, aggfunc = aggfunc_2)
        data_pivot = data_pivot / data_pivot_2      
    
    # находим ячейки с минимальным значением
    # и выводим в каких уникальных значениях района и категории находятся мин. значение
    min_value = data_pivot.min().min()
    contains_value = data_pivot.eq(min_value).any()
    columns_with_value_1 = contains_value[contains_value == True].index.to_list()
    contains_value = data_pivot.eq(min_value).any(axis=1)
    columns_with_value_2 = contains_value[contains_value == True].index.to_list()
    for i in range(len(columns_with_value_1)):
        print('Мин. значение {:.2f} (не отнормированное) для {} в {}'.format(min_value, 
                                                        columns_with_value_1[i],
                                                        columns_with_value_2[i]))
    
    # нормируем показатели каждого параметра
    # относительно минимального значения по всему датафрейму
    for column in data_pivot.columns:
        data_pivot[column] = ((data_pivot[column] - min_value) 
                         / (min_value))
    
    # сортируем данные
    data_pivot['mean_all_1'] = data_pivot.mean(axis=1)
    data_pivot=data_pivot.sort_values(by='mean_all_1')
    data_pivot=data_pivot.drop('mean_all_1', axis=1)
    data_pivot=data_pivot.T
    data_pivot['mean_all_2'] = data_pivot.mean(axis=1)
    data_pivot=data_pivot.sort_values(by='mean_all_2',ascending=False)
    data_pivot=data_pivot.drop('mean_all_2', axis=1)
    
    # строим хитмеп
    plt.figure(figsize=(10, 4.5))
    ax = sns.heatmap(
        data_pivot,
        annot=True,
        fmt=fmt,
        cmap= 'coolwarm',
        linecolor='black',
        linewidths=2,
        vmax=vmax)

    # настраиваем параметры хитмепа
    ax.tick_params(axis='x', labelsize=9)
    plt.xlabel('x_label')
    ax.xaxis.set_ticks_position('top')
    plt.title(title)
    plt.xlabel('')
    plt.ylabel('')

    # выводим хитмеп
    plt.show()
    
    # возвращаем таблицу, по которой строился хитмеп
    return data_pivot.T

Пояснение к хитмепам, построенным ниже:

  • Чем холоднее ячейки - тем меньше конкуренция по параметру, чем теплее - тем выше конкуренция по параметру.
  • Цвет ячеек показывает разницу в значениях относительно минимального значения в сводной таблице (например, если на хитмепе для параметра "Число заведений" значение 2 - это значит, что эта ячейка содержит в два раза больше заведений, чем в ячейке с минимальным количеством заведений от всех значений в ячейках по таблице)
  • За верхний предел покраски установлено значение 1. То есть все самые красные значения - это значения, которые в два или более раза выше минимального значения по таблице.

Посмотрим на распределение количества заведений по категориям и районам.

In [44]:
# строим хитмеп значений количества заведений в районах и категориях
df_chois_dstr_1 = heatmap_norm(data_in = data,
            column1 = 'dstr',
            column2 = 'category',
            values = 'name',
            aggfunc = 'count',
            title = 'Количество заведений по категориям и районам\n',
            fmt='.2f')
Мин. значение 12.00 (не отнормированное) для булочная в СЗАО

Вывод:

  • Меньше всего прямых конкурентов явно для булочной в СЗАО и ЮВАО
In [45]:
# строим хитмеп значений количества заведений на 1 кв.км. в районах и категориях
df_chois_dstr_2 = heatmap_norm(data_in = data,
                  column1 = 'dstr',
                  column2 = 'category',
                  values = 'name',
                  aggfunc = 'count',
                  values_2 = 'area',
                  aggfunc_2 = 'mean',
                  title = 'Количество заведений на 1 кв. км. по категориям и районам \n')
Мин. значение 0.11 (не отнормированное) для булочная в ЮВАО

Вывод:

  • Меньше всего прямых конкурентов на 1 км² будет для булочной в ЮВАО И СЗАО
In [46]:
# строим хитмеп значений среднего рейтинга заведений в районах и категориях
df_chois_dstr_3 = heatmap_norm(data_in = data,
            column1 = 'dstr',
            column2 = 'category',
            values = 'rating',
            aggfunc = 'mean',
            title = 'Средний рейтинг по категориям и районам\n')
Мин. значение 3.93 (не отнормированное) для быстр.пит. в ЮВАО

Вывод:

  • Как видно по таблице изменения в среднем рейтинге почти не влияют на выбор заведения - относительные изменения незначительны. Однако если значения рейтинга для нас важны, можно сказать, что наиболее слабые конкуренты будут скорее всего для быстрого питания в ЮВАО, СЗАО, ЗАО, САО.

Итоговый вывод:

Лучше всего открыть булочную в СЗАО.
Причины:

  • По абсолютному числу прямых конкурентов и их числу на 1 км² лучше всего открывать булочную в СЗАО или ЮВАО. По абсолютному числу непрямых конкурентов лучше открывать новое заведения в СЗАО. Итого: лучше всего открыть новую булочную в СЗАО. Средний рейтинг и прямых и непрямых конкурентов в СЗАО не самый низкий, однако средний рейтинг почти не влияет на выбор заведения или района, поэтому разницу в среднем рейтинге можно не учитывать.

Общие выводы по итогам анализа рынка заведений общепита в Москве:

Если нашей целью является выбор наиболее предпочительного типа и расположения нового заведения, то можно дать следующие рекомендации:

  • Общие рекомендации по типу заведения

    • Чем больше количество заведений того же типа в округе - тем выше конкуренция среди заведений этого типа. Во всех округах на первом месте по количеству заведений идут кафе, на втором - рестораны. Кроме ЦАО: в нем на первом месте идут рестораны, на втором - кафе. Лучше точно не открывать рестораны и кафе. Во всех округах меньше всего булочных и столовых и соотственно среди них меньше всего конкуренция.
    • Чем выше средний рейтинг заведений - тем выше конкуренция. В быстром питании и кафе самые низкие рейтинги. Выше всего рейтинги у пабов/баров. Если ориентироваться только на рейтинг - то лучше всего открывать быстрое питание или кафе, не стоит открывать пабы/бары - среди них много сильных конкурентов с хорошими рейтингами. Стоит сказать, что относительное различие в среднем рейтинге между категориями совсем небольшое, поэтому его можно не учитывать.
  • Общие рекомендации по расположению заведения (округ)

    • Для открытия нового заведения точно не стоит выбирать ЦАО - там итак уже слишком много заведений общепита как количественно, так и на 1 кв.км, поэтому там сильная конкуренция. Меньше всего конкуренция будет в СЗАО - там меньше всего заведений общепита.
    • В ЦАО самый высокий средний рейтинг заведений, а значит больше сильных конкурентов. Самые низкий рейтинг - в ЮВАО, если сделать хорошее заведение в этом округе, то будет легче всего выделиться среди конкурентов. Стоит сказать, что относительное различие в среднем рейтинге между районами (также как и между категориями) совсем небольшое, поэтому его можно не учитывать.
  • Общие рекомендации по расположению заведения (улица)

    • Если есть цель открыть заведение на одной из наиболее полулярных улиц по количеству заведений, то в первую очередь стоит ориентироваться на количество заведений на улице. Больше всего заведений на проспекте Мира. Сооответственно, там самая высокая конкуренция, эту улицу точно выбирать не стоит. Меньше всего конкурентов будет на Пятницкой улице - там меньше всего заведений. Почти на всех улицах меньше всего булочных и столовых. Поэтому скорее всего лучше будет открыть булочную или столовую на Пятницкой улице.

Выбор места для открытия кофейни¶

Посмотрим, сколько всего у нас кофеен

In [47]:
print('Всего кофеен: {}'.format(data[data['category'] == 'кофейня']['name'].count()))
Всего кофеен: 1413

Посмотрим распределение числа кофеен по районам

In [48]:
# готовим данные для графика
df=(data[data['category'] == 'кофейня'].groupby('dstr',as_index = False)['name'].count()
    .rename(columns={'name': 'count_coff'})
    .sort_values(by='count_coff'))

print(df)

# строим график
fig = px.bar(df, 
             y='dstr',
             x='count_coff',
             text='count_coff')

# настраиваем параметры графика
fig.update_layout(title={'text': 'Количество кофеен по районам','x': 0.5},
                  xaxis_title='Число кофеен',
                  yaxis_title='Район',
                  width=800,
                  height=600)
fig.update_xaxes(tickvals=[])

# выводим график
fig.show()
   dstr  count_coff
4  СЗАО          62
7  ЮВАО          89
8  ЮЗАО          96
0   ВАО         105
6   ЮАО         131
1   ЗАО         150
3  СВАО         159
2   САО         193
5   ЦАО         428

Выводы:

  • Наибольшее количество кофеен в ЦАО. Наименьшее - в СЗАО.

Посмотрим распределение доли кофеен относительно числа заведений всех типов в разбивке по районам

In [49]:
# готовим данные для графиков
df = data.copy()
df['all_count_dstr'] = df.groupby('district')['name'].transform('count')
df = (df[df['category'] == 'кофейня'].groupby(['district','all_count_dstr'], as_index = False)['name'].count()
      .rename(columns={'name': 'count_coff'}))
df['perc_coff'] = round(df['count_coff'] / df['all_count_dstr'] * 100,2)

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='perc_coff',
         legend_name='Процент кофеен')
district perc_coff
0 Северный административный округ 21.44
1 Центральный административный округ 19.09
2 Северо-Восточный административный округ 17.85
3 Западный административный округ 17.63
4 Северо-Западный административный округ 15.16
5 Южный административный округ 14.69
6 Юго-Западный административный округ 13.54
7 Восточный административный округ 13.16
8 Юго-Восточный административный округ 12.46
Out[49]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Выводы:

  • Наибольший процент кофеен среди всех заведений в САО. Наименьший - в ЮВАО.

Теперь посмотрим на число кафеен на 1 квадратных километр площади по районам.

In [50]:
# готовим данные для графиков
df = data[data['category'] == 'кофейня'].groupby(['district','area'],as_index = False)['name'].count()

# считаем число заведений на 1 кв.км.
df['count_per_area'] = round(df['name'] / df['area'],2)

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='count_per_area',
         legend_name='Число кафеен на 1 кв.км.')
district count_per_area
0 Центральный административный округ 6.47
1 Северный административный округ 1.70
2 Северо-Восточный административный округ 1.56
3 Южный административный округ 0.99
4 Западный административный округ 0.98
5 Юго-Западный административный округ 0.86
6 Юго-Восточный административный округ 0.76
7 Восточный административный округ 0.68
8 Северо-Западный административный округ 0.66
Out[50]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • В центральном округе число кофеен на квадратный километр явно выше, чем в других районах.

Чтобы визуально лучше сравнить разницу в значениях числа кофеен на 1 кв.км по районам посмотрим еще на распределение числа кофеен на 1 кв.км. без ЦАО.

In [51]:
# готовим данные
df = data[data['category'] == 'кофейня'].groupby(['district','area'],as_index = False)['name'].count()

# считаем число заведений на 1 кв.км.
df['count_coff_per_area'] = round(df['name'] / df['area'],2)

# удаляем ЦАО
df = df[df['district'] != 'Центральный административный округ']

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='count_coff_per_area',
         legend_name='Число кафеен на 1 кв.км.',
         nan_fill_opacity=0.2)
district count_coff_per_area
0 Северный административный округ 1.70
1 Северо-Восточный административный округ 1.56
2 Южный административный округ 0.99
3 Западный административный округ 0.98
4 Юго-Западный административный округ 0.86
5 Юго-Восточный административный округ 0.76
6 Восточный административный округ 0.68
7 Северо-Западный административный округ 0.66
Out[51]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • Если не брать в расчет ЦАО, плотность кафеен на 1 кв. км. выше всего в САО, меньше всего - в СЗАО и ВАО.

Посмотрим сколько всего круглосуточных кофеен и их процент относительно всех кофеен.

In [52]:
all_coff = data[data['category'] == 'кофейня']['name'].count()
coff_24hour = data[(data['category'] == 'кофейня') & (data['is_24/7'] == True)]['name'].count()
print('Круглосуточных кофеен {}, что составляет {:.1%} от всех кофеен.'.format(coff_24hour,coff_24hour/all_coff))
Круглосуточных кофеен 59, что составляет 4.2% от всех кофеен.

Посмотрим, где располагаются круглосуточные кофейни

In [53]:
# готовим данные для маркеров круглосуточных кофеен
df = data[(data['category'] == 'кофейня') & data['is_24/7']]

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='buf',
         legend_name='',
         markers_on=True,
         fill_opacity=0,
         zoom_start=11)
Out[53]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • Круглосуточных кофеен очень мало (меньше 5% всех кофеен) и большинство из них располагается в ЦАО.

Посмотрим как распределяются средние рейтинги кофеен по районам

In [54]:
# готовимы данные для отображения
df = (data[data['category'] == 'кофейня'].groupby('district', as_index=False)['rating']
      .agg('mean').round(2).rename(columns={'rating': 'rating_mean_coff'}))

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='rating_mean_coff',
         legend_name='Средний рейтинг кофеен')
district rating_mean_coff
0 Центральный административный округ 4.34
1 Северо-Западный административный округ 4.33
2 Северный административный округ 4.29
3 Восточный административный округ 4.28
4 Юго-Западный административный округ 4.28
5 Юго-Восточный административный округ 4.23
6 Южный административный округ 4.23
7 Северо-Восточный административный округ 4.22
8 Западный административный округ 4.20
Out[54]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Выводы:

  • Наиболее высокий средний рейтинг у кафеен в ЦАО и СЗАО. Наиболее низкий - в ЗАО.

Посмотрим на медианную стоимость чаши капучино по районам. Лучше брать медиану, а не среднее, так как в ценах есть выбросы.

In [57]:
# готовим данные
df = (data[data['category'] == 'кофейня'].groupby('district', as_index=False)['middle_coffee_cup'].agg('median')
      .rename(columns={'middle_coffee_cup': 'median_coffee_cup'}))

# строим хороплет
map_chor(df_in=df,
         column_1='district',
         column_2='median_coffee_cup',
         legend_name='Медианная стоимость чашки капучино')
district median_coffee_cup
0 Юго-Западный административный округ 198.0
1 Центральный административный округ 190.0
2 Западный административный округ 189.0
3 Северо-Западный административный округ 165.0
4 Северо-Восточный административный округ 162.5
5 Северный административный округ 159.0
6 Южный административный округ 150.0
7 Юго-Восточный административный округ 147.5
8 Восточный административный округ 135.0
Out[57]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Вывод:

  • Самая высокая медианная стоимость чашки капучино в ЮЗАО. Самая низкая - в ВАО.

Итоговая оценка параметров по выбору места для кофейни¶

Открывать кофейню стоит там, где меньше конкуренция.

  • Прямые конкуренты - это конкуренты того же типа (конкуренция кофеен среди кофеен).
  • Непрямые конкуренты - это конкуренты среди остальных заведений общепита (конкуренция кофеен с другими типами заведений общепита: кафе, ресторанами и т.д.).

На конкуренцию влияют следующие параметры (чем ниже любой из этих параметров - тем ниже конкуренция):

  1. Абсолютное число прямых и непрямых заведений-конкурентов
  2. Число прямых и непрямых заведений-конкурентов на единицу площади
  3. Средний рейтинг прямых и непрямых заведений-конкурентов

Для более детальной оценки суммарного влияния данных параметров на выбор района для открытия кофейни, построим хитмеп отнормированных параметров конкуренции по каждому району (нормировка - относительно минимального значения параметра). Добавим к основным параметрам еще число круглосуточных кофеен, чтобы оценить относительную разницу в их количестве по районам.

Пояснение к хитмепу, построенному ниже:

  • Чем холоднее ячейки - тем меньше конкуренция по параметру, чем теплее - тем выше конкуренция по параметру.
  • Цвет ячеек показывает разницу в значениях относительно минимального значения внутри одного параметра (например, если на хитмепе у параметра "Число заведений" значение 2 - это значит, что эта ячейка содержит в два раза больше числа заведений, чем в ячейке с минимальным количеством заведений)
  • За верхний предел покраски установлено значение 1. То есть все самые красные значения - это значения, которые в два или более раза больше минимального значения внутри одного параметра.
In [56]:
# считаем количество кофеен по районам
df1 = (data[data['category'] == 'кофейня'].groupby('dstr',as_index = False)['name'].count()
    .rename(columns={'name': 'count_coff'}))

# считаем число кофеен на 1 кв км по районам
df2 = data[data['category'] == 'кофейня'].groupby(['dstr','area'], as_index = False)['name'].count()
df2['count_coff_per_area'] = df2['name'] / df2['area']
df2=df2[['dstr','count_coff_per_area']]

# считаем средний рейтинг кофеен по районам
df3 = (data[data['category'] == 'кофейня'].groupby('dstr', as_index=False)['rating']
      .agg('mean').round(2).rename(columns={'rating': 'rating_mean_coff'}))

# считаем сколько кофеен 24/7 по районам
df4 = (data[(data['category'] == 'кофейня') & (data['is_24/7'])].groupby('dstr', as_index=False)['name'].count()
      .rename(columns={'name': 'count_coff_24/7'}))

# считаем количество всех заведений по районам
df5 = (data.groupby('dstr',as_index = False)['name'].count()
    .rename(columns={'name': 'count_all'}))

# считаем число заведений на 1 кв км по районам
df6 = data.groupby(['dstr','area'], as_index = False)['name'].count()
df6['count_all_per_area'] = df6['name'] / df6['area']
df6=df6[['dstr','count_all_per_area']]

# считаем средний рейтинг кофеен по районам
df7 = (data.groupby('dstr', as_index=False)['rating']
      .agg('mean').round(2).rename(columns={'rating': 'rating_mean_all'}))

# объединяем все параметры в одну таблицу
df_vs = (df1
         .merge(df2, on='dstr')
         .merge(df3, on='dstr')
         .merge(df4, on='dstr')
         .merge(df5, on='dstr')
         .merge(df6, on='dstr')
         .merge(df7, on='dstr')
        )

# помещаем названия районов в индексы
df_vs = df_vs.set_index('dstr')

# нормируем показатели каждого параметра
for column in df_vs.columns:
    df_vs[column] = ((df_vs[column] - df_vs[column].min()) 
                          / (df_vs[column].min()))
    
# переименуем столбцы для отображения
df_vs=df_vs.rename(columns={'count_coff': 'Число кофеен',
                           'count_coff_per_area': 'Число кофеен на 1 кв. км.',
                           'rating_mean_coff': 'Средний рейтинг кофеен',
                           'rating_mean_all': 'Средний рейтинг всех заведений',
                           'count_all_per_area': 'Число всех заведений на 1 кв.км.',
                           'count_all': 'Число всех заведений',
                           'count_coff_24/7': 'Число кофеен 24/7'})

# сортируем данные
df_vs['mean_all_1'] = df_vs.mean(axis=1)
df_vs=df_vs.sort_values(by='mean_all_1')
df_vs=df_vs.drop('mean_all_1', axis=1)
df_vs=df_vs.T
df_vs['mean_all_2'] = df_vs.mean(axis=1)
df_vs=df_vs.sort_values(by='mean_all_2',ascending=False)
df_vs=df_vs.drop('mean_all_2', axis=1)

# строим хитмеп параметров
plt.figure(figsize=(9, 5))
ax = sns.heatmap(
    df_vs,
    annot=True,
    cmap= 'coolwarm',
    linecolor='black',
    linewidths=2,
    fmt='.2f',
    vmax=1)

# настраиваем параметры хитмепа
ax.xaxis.set_ticks_position('top')
plt.title('Значения параметров конкуренции для кофеен по районам\n')
plt.xlabel('')
plt.ylabel('')

# выводим хитмеп
plt.show()

Вывод:

Меньше всего конкурентов для кофеен по:

  • Абсолютному числу прямых конкурентов (т.е. по числу кофеен) - СЗАО
  • Абсолютному число непрямых конкурентов (т.е. по общему числу заведений общепита) - СЗАО
  • Абсолютному числу прямых конкурентов на 1 кв.км (т.е. по числу кофеен на 1 кв.км)- СЗАО, ВАО
  • Абсолютному числу непрямых конкурентов на 1 кв.км (т.е. по общему числу заведений общепита на 1 кв.км.) - СЗАО
  • Среднему рейтингу прямых конкурентов (т.е. по кофейням)- ЮВАО (разница незначительна)
  • Среднему рейтингу непрямых конкурентов (т.е. по всем заведениям общепита)- ЮВАО (разница незначительна)

Итоговый вывод по выбору места для открытия кофейни:

Рекомендуется открыть круглосуточную кофейню в СЗАО.
При открытии стоит ориентироваться на стоимость чаши капучино ~165 руб

Причины:

  • По совокупной оценке ключевых параметров конкуренции этот округ лучший среди всех округов. По всем параметрам конкуренции СЗАО в числе антилидеров, кроме среднего рейтинга. Однако стоит сказать, что разница в среднем рейтинге незначительна, поэтому ее скорее можно не учитывать.
  • Круглосуточных кофеен в Москве почти нет (меньше 5% всех кофеен), а значит почти нет конкурентов в этой нише. Если есть желание открыть кофейню, то лучше всего открывать именно круглосуточную кофейню.